가변 길이 구조체(Struct Hack)와 필러 필드 — 헤더 뒤에 매달리는 가변 payload

NOTE

헤더(고정 크기 메타데이터)와 가변 길이 바디(payload)를 하나의 연속된 메모리 블록으로 표현하는 struct hack 관용구와, 그 표준화된 버전인 C99 유연 배열 멤버(flexible array member)를 정리한다. 구조체 크기를 인위적으로 고정해두는 필러(filler) 필드 관례도 함께 다룬다.

0. 왜 필요한가

메시지 하나를 “고정 크기 헤더 + 길이가 매번 다른 바디”로 표현해야 하는 경우가 흔하다. 헤더와 바디를 서로 다른 두 개의 malloc으로 나눠 관리할 수도 있지만, 그러면 메시지를 통째로 한 번에 복사하거나 소켓으로 보낼 때 포인터를 두 번 따라가야 해서 번거롭다. 헤더와 바디를 하나의 연속된 메모리 블록으로 만들고 싶다는 요구에서 나온 관용구가 struct hack이다.

1. 기본형 — 크기 1(또는 0)짜리 트레일링 배열

typedef struct {
    unsigned int payload_len;   /* 실제 페이로드 길이 */
    int          msg_type;
    char         data[1];        /* 실제로는 이보다 훨씬 길게 할당된다 — "데이터 시작 지점" 표시일 뿐 */
} msg_t;

data[1]은 진짜로 1바이트짜리 배열을 뜻하는 게 아니라, “이 구조체 몸통이 끝나는 지점, 즉 페이로드가 시작하는 주소”를 가리키는 이름표로 쓰인다.

2. 실제로 할당하고 채우는 법

#include <stdlib.h>
#include <string.h>
 
msg_t *msg_create(const char *payload, unsigned int len)
{
    msg_t *msg = malloc(sizeof(msg_t) + len);   /* 헤더 크기 + 실제 데이터 길이만큼 넉넉히 할당 */
    if (msg == NULL) {
        return NULL;
    }
 
    msg->payload_len = len;
    msg->msg_type = 1;
    memcpy(msg->data, payload, len);   /* data는 1바이트로 선언돼 있지만 len바이트를 써도 할당받은 범위 안이다 */
 
    return msg;
}

컴파일러는 data를 1바이트짜리 배열로만 알고 있지만, msg_create가 실제로 그보다 훨씬 큰 메모리를 할당했기 때문에 data[0]부터 data[len-1]까지 접근해도 할당받은 메모리 범위를 벗어나지 않는다. 이건 C 언어가 공식으로 보장하는 동작이 아니라 관행적으로 통하는 트릭이라, 배열 경계를 검사하는 정적 분석 도구가 여기서 종종 (거짓) 경고를 낸다.

3. C99 유연 배열 멤버(Flexible Array Member) — 표준화된 버전

typedef struct {
    unsigned int payload_len;
    int          msg_type;
    char         data[];   /* 크기를 아예 안 씀 — C99가 정식으로 인정한 "유연 배열 멤버" */
} msg_t;
 
msg_t *msg = malloc(sizeof(msg_t) + len);   /* sizeof(msg_t)가 data 크기를 전혀 포함하지 않는다 */

[1]/[0]로 쓰던 예전 스타일과 달리, [](크기 생략)는 C99부터 언어가 공식으로 정의한 문법이다. 가장 큰 차이는 sizeof(msg_t)가 data 필드의 크기를 아예 포함하지 않는다는 점이다 — [1] 스타일은 1바이트가 이미 포함돼 있어 할당 크기 계산 시 이 1바이트를 신경 써야 하지만, [] 스타일은 그럴 필요가 없다.

4. 왜 레거시 코드엔 여전히 [1] 스타일이 많은가

C99 이전(또는 C99를 완전히 지원하지 않던 컴파일러) 시절부터 굳어진 관행이기도 하고, 이미 그 크기 계산 방식(sizeof(msg_t) + len)에 맞춰 짜인 코드가 곳곳에 퍼진 상태에서 필드 선언만 []로 바꾸면 sizeof 값이 달라져 관련된 모든 malloc 크기 계산 코드를 다시 점검해야 한다. 그래서 오래된 코드베이스는 새 구조체가 아니면 [1] 스타일을 그대로 유지하는 경우가 많다.

5. 필러(filler) 필드 — 구조체 크기를 인위적으로 고정해두는 관례

typedef struct {
    int  n_value;
#if 1   /* 필드 하나를 새로 추가하면서 sizeof(struct)는 그대로 유지하고 싶을 때 */
    int  n_new_field;
    char sz_filler[4];   /* 새 필드가 차지한 만큼 필러를 줄여서 전체 크기를 맞춘다 */
#else
    char sz_filler[8];   /* 필드 추가 전에는 필러가 더 컸다 */
#endif
} record_t;

구조체를 그대로 IPC로 주고받거나 파일에 직렬화하는 코드에서는 구조체의 메모리 레이아웃 자체가 통신 규격이 된다(실무 C 코드 관례와 UB 함정 정리의 B12). 필드를 추가/제거할 때마다 이 구조체를 쓰는 모든 프로세스·바이너리를 한꺼번에 다시 빌드할 수 있다면 문제없지만, 그게 어려운 상황(구버전과의 호환 유지, 배포 시점이 서로 다른 여러 프로세스)이라면 여분 공간을 미리 확보해두고, 새 필드를 넣을 때마다 그 여분을 깎아 쓰는 방식으로 sizeof(record_t) 자체를 고정해 하위 호환성을 지킨다.

6. 자주 하는 실수

  • malloc 크기 계산에서 트레일링 배열 크기를 혼동하기 — [1] 스타일은 sizeof(msg_t)에 이미 1바이트가 포함돼 있으므로 malloc(sizeof(msg_t) + len)은 사실 1바이트를 더 여유 있게 할당하는 셈이다(대개 무해하지만, 정확히 맞추려면 sizeof(msg_t) - 1 + len으로 계산하는 코드도 있다). [] 스타일은 이 보정이 필요 없다. 두 스타일의 계산 공식을 섞어 쓰면 한쪽 기준으로 짠 코드를 다른 쪽에 그대로 옮겼을 때 오프바이원 버그가 생긴다.
  • 이 구조체를 값으로 복사하거나 배열/지역 변수로 선언하기 — msg_t m = *ptr;이나 msg_t arr[10];처럼 쓰면, 컴파일러가 아는 크기(헤더 + 트레일링 배열의 선언된 크기)만큼만 복사·할당되어 실제 페이로드는 통째로 잘려나간다. 이런 구조체는 항상 포인터로만 다루고 반드시 힙에만 존재해야 한다.
  • 유연 배열 멤버(data[]) 뒤에 다른 필드를 또 선언하기 — 표준상 유연 배열 멤버는 반드시 구조체의 마지막 멤버여야 한다. 그 뒤에 필드를 추가하면 컴파일 에러다.
  • 필러 필드의 크기를 계산 없이 대충 줄이기 — 필러는 “새로 추가한 필드가 차지한 바이트 수만큼 정확히” 줄여야 전체 구조체 크기가 유지된다. 얼마나 줄여야 하는지 계산하지 않고 대충 줄이면, 그 구조체를 공유하는 다른 쪽(예전 빌드, 다른 프로세스)과 레이아웃이 미묘하게 어긋나 특정 필드만 쓰레기 값으로 읽히는 디버깅하기 어려운 문제가 생긴다.

7. 실전 사례 — length + 가변 바이트 (가변 바이너리 타입)

DB·통신 라이브러리가 제공하는 가변 길이 바이너리 타입(OCTETS, BLOB 계열)은 대개 이 패턴으로 만들어져 있다. 필드 값이 length와 그 뒤에 이어 붙는 바이트 열 하나로 표현된다.

typedef struct octets {
    int  length;        /* 실제 데이터 바이트 수 */
    char data[1];       /* flexible array — 데이터 시작 위치 표시 (선언 크기 1, 실제는 length 바이트) */
} octets_t;

왜 길이 필드가 필수인가

  • char * 문자열은 끝에 \0이 있어 strlen으로 길이를 알 수 있다.
  • 바이너리는 값 중간에 0x00이 있을 수 있고 끝 표시도 없다 → length가 유일한 길이 정보다. strlen·strcpy·%s를 쓰면 안 되고 memcpy/length 기준 루프를 쓴다.

값 추가 (생성)

octets_t *o;
size_t    n = 4;
 
o = malloc(offsetof(octets_t, data) + n);   /* 헤더(length) + 데이터 n바이트 */
if (o == NULL) { /* error */ }
 
o->length  = n;                             /* length를 반드시 먼저 채운다 */
o->data[0] = 0x11;
o->data[1] = 0x22;
o->data[2] = 0x33;
o->data[3] = 0xFF;
/* 또는: memcpy(o->data, src, n); */
 
/* ... 라이브러리 호출에 o를 넘긴다 ... */
free(o);
  • 할당 크기 = length 필드 크기 + 데이터 n바이트. malloc(sizeof(int) + n)으로도 쓰지만, offsetof(octets_t, data) + n이 같은 값을 의도가 드러나게 표현한다.
  • sizeof(octets_t) + n은 [1] 스타일에서 패딩 포함 8바이트(x86-64)라 4바이트가 더 할당된다 (동작은 하지만 낭비 — 위 6번 항목의 “계산 공식 혼동” 참고).
  • 값을 읽는 쪽은 length만 보고 몇 바이트를 가져갈지 정하므로 length가 틀리면 잘리거나 쓰레기가 섞인다.

값 접근 (읽기)

int j;
 
printf("len=%d 0x", o->length);
for (j = 0; j < o->length; j++) {
    printf("%02x", o->data[j] & 0xff);      /* & 0xff — char 부호 확장 방지 */
}
접근방법
길이o->length
i번째 바이트o->data[i] (0 ≤ i < length)
전체 복사memcpy(dst, o->data, o->length) (dst는 length 이상 확보)
구조체째 복사memcpy(dst, o, offsetof(octets_t, data) + o->length) — *dst = *o 값 복사는 금지
16진 출력%02x + & 0xff (char가 signed면 0x80 이상이 ffffff..로 출력됨)
  • 라이브러리가 돌려준 포인터가 내부 버퍼(결과집합 등)를 가리키는 경우가 많으므로, 수정이 필요하면 다른 버퍼에 복사한 뒤 수정한다.
  • 길이가 스키마로 고정된 바이너리(BINARY(N) 류)는 이 구조체 없이 char *와 N으로 다루는 경우가 많다.
  • 구조체 배열이 이어 붙는 구조(요소 반복)는 버퍼를 구조체로 캐스팅해 읽기를 참고.

관련 문서